Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 5ef41b84d5243538f28f8ace3709b6f9f0100446


Parents : ad928d1
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-04T17:01:21-06:00

feat(call): update call statistics tracking and improve hangup functionality; add tone generation for call states in frontend

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 8f8fda9f..24fb8d5e 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -3910,7 +3910,17 @@ class ReticulumMeshChat:
"is_voicemail": self.voicemail_manager.is_recording,
"call_start_time": self.telephone_manager.call_start_time,
"is_contact": contact is not None,
+ "tx_bytes": 0,
+ "rx_bytes": 0,
+ "tx_packets": 0,
+ "rx_packets": 0,
}
+ link = getattr(self.telephone_manager, "call_stats", {}).get("link")
+ if link:
+ active_call["tx_bytes"] = getattr(link, "txbytes", 0)
+ active_call["rx_bytes"] = getattr(link, "rxbytes", 0)
+ active_call["tx_packets"] = getattr(link, "tx", 0)
+ active_call["rx_packets"] = getattr(link, "rx", 0)
initiation_target_hash = self.telephone_manager.initiation_target_hash
initiation_target_name = None
@@ -3973,7 +3983,7 @@ class ReticulumMeshChat:
# hangup active telephone call
@routes.get("/api/v1/telephone/hangup")
async def telephone_hangup(request):
- await asyncio.to_thread(self.telephone_manager.telephone.hangup)
+ await asyncio.to_thread(self.telephone_manager.hangup)
return web.json_response(
{

diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index ceebab91..231f10c3 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -74,7 +74,10 @@ class TelephoneManager:
@property
def is_recording(self):
- # Disabled for now
+ # Check if voicemail manager or this manager is recording
+ # This is a bit of a hack since we don't have a direct link to voicemail_manager here
+ # but we can check if our own recording is active if we had it.
+ # For now, we'll just return False and let meshchat.py handle the combined status.
return False
def init_telephone(self):
@@ -101,6 +104,16 @@ class TelephoneManager:
self.telephone.teardown()
self.telephone = None
+ def hangup(self):
+ if self.telephone:
+ try:
+ self.telephone.hangup()
+ except Exception as e:
+ RNS.log(f"TelephoneManager: Error during hangup: {e}", RNS.LOG_ERROR)
+
+ # Always clear initiation status on hangup to prevent "Dialing..." hang
+ self._update_initiation_status(None, None)
+
def register_ringing_callback(self, callback):
self.on_ringing_callback = callback
@@ -111,6 +124,12 @@ class TelephoneManager:
self.on_ended_callback = callback
def on_telephone_ringing(self, caller_identity: RNS.Identity):
+ if self.initiation_status:
+ # This is an outgoing call where the remote side is now ringing.
+ # We update the initiation status to "Ringing..." for the UI.
+ self._update_initiation_status("Ringing...")
+ return
+
self.call_start_time = time.time()
self.call_is_incoming = True
self.call_was_established = False
@@ -121,6 +140,15 @@ class TelephoneManager:
# Update start time to when it was actually established for duration calculation
self.call_start_time = time.time()
self.call_was_established = True
+
+ # Clear initiation status as soon as call is established
+ self._update_initiation_status(None, None)
+
+ # Track per-call stats from the active link (uses RNS Link counters)
+ link = getattr(self.telephone, "active_call", None)
+ self.call_stats = {
+ "link": link,
+ }
# Recording disabled for now due to stability issues with LXST
# if self.config_manager and self.config_manager.call_recording_enabled.get():
@@ -134,6 +162,9 @@ class TelephoneManager:
if self.telephone:
self.call_status_at_end = self.telephone.call_status
+ # Ensure initiation status is cleared when call ends
+ self._update_initiation_status(None, None)
+
if self.on_ended_callback:
self.on_ended_callback(caller_identity)
@@ -189,30 +220,46 @@ class TelephoneManager:
try:
def resolve_identity(target_hash_hex):
+ """Resolve identity from multiple hints: direct recall, destination_hash announce, identity_hash announce, or public key."""
target_hash = bytes.fromhex(target_hash_hex)
- # 1. Try RNS recall
+
+ # 1) Direct recall (identity hash)
ident = RNS.Identity.recall(target_hash)
if ident:
return ident
- # 2. Check DB announces
- if self.db:
- announce = self.db.announces.get_announce_by_hash(target_hash_hex)
- if announce:
- # Try recalling identity hash from announce
- identity_hash = bytes.fromhex(announce["identity_hash"])
- ident = RNS.Identity.recall(identity_hash)
- if ident:
- return ident
-
- # Try reconstructing from public key if recall failed
- if announce.get("identity_public_key"):
- try:
- return RNS.Identity.from_bytes(
- base64.b64decode(announce["identity_public_key"])
- )
- except Exception:
- pass
+ if not self.db:
+ return None
+
+ # 2) By destination_hash (could be lxst.telephony or lxmf.delivery hash)
+ announce = self.db.announces.get_announce_by_hash(target_hash_hex)
+ if not announce:
+ # 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
+ announces = self.db.announces.get_filtered_announces(
+ identity_hash=target_hash_hex
+ )
+ if announces:
+ announce = announces[0]
+
+ if not announce:
+ return None
+
+ # Try identity_hash from announce
+ identity_hex = announce.get("identity_hash")
+ if identity_hex:
+ ident = RNS.Identity.recall(bytes.fromhex(identity_hex))
+ if ident:
+ return ident
+
+ # Try reconstructing from public key
+ if announce.get("identity_public_key"):
+ try:
+ return RNS.Identity.from_bytes(
+ base64.b64decode(announce["identity_public_key"])
+ )
+ except Exception:
+ pass
+
return None
# Find destination identity
@@ -249,9 +296,21 @@ class TelephoneManager:
self._update_initiation_status("Dialing...")
self.call_start_time = time.time()
self.call_is_incoming = False
+
+ # Use a thread for the blocking LXST call, but monitor status for early exit
+ # if established elsewhere or timed out/hung up
+ call_task = asyncio.create_task(asyncio.to_thread(self.telephone.call, destination_identity))
+
+ start_wait = time.time()
+ # LXST telephone.call usually returns on establishment or timeout.
+ # We wait for it, but if status becomes established or ended, we can stop waiting.
+ while not call_task.done():
+ if self.telephone.call_status in [6, 0, 1]: # Established, Busy, Rejected
+ break
+ if self.telephone.call_status == 3 and (time.time() - start_wait > 1.0): # Available (ended/timeout)
+ break
+ await asyncio.sleep(0.5)
- # Use a thread for the blocking LXST call
- await asyncio.to_thread(self.telephone.call, destination_identity)
return self.telephone.active_call
except Exception as e:

diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index 3b2e883b..629da486 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -38,7 +38,7 @@ class VoicemailManager:
self.on_new_voicemail_callback = None
# stabilization delay for voicemail greeting
- self.STABILIZATION_DELAY = 2.5
+ self.STABILIZATION_DELAY = 1.0
# Paths to executables
self.espeak_path = self._find_espeak()
@@ -286,13 +286,24 @@ class VoicemailManager:
if not telephone:
return
- # Answer the call
- if not telephone.answer(caller_identity):
+ # Answer the call if it's still ringing
+ if telephone.call_status == 4: # STATUS_RINGING
+ if not telephone.answer(caller_identity):
+ RNS.log("Voicemail: Failed to answer call", RNS.LOG_ERROR)
+ return
+ elif telephone.call_status != 6: # STATUS_ESTABLISHED
+ RNS.log(
+ f"Voicemail: Cannot start session, call status is {telephone.call_status}",
+ RNS.LOG_DEBUG,
+ )
return
# Stop microphone if it's active to prevent local noise being sent or recorded
if telephone.audio_input:
- telephone.audio_input.stop()
+ try:
+ telephone.audio_input.stop()
+ except Exception:
+ pass
# Play greeting
greeting_path = os.path.join(self.greetings_dir, "greeting.opus")
@@ -313,6 +324,13 @@ class VoicemailManager:
)
def session_job():
+ prev_receive_muted = self.telephone_manager.receive_muted
+ try:
+ # Prevent remote audio from playing locally while recording voicemail
+ self.telephone_manager.mute_receive()
+ except Exception:
+ pass
+
try:
# Wait for link to stabilize
RNS.log(
@@ -398,6 +416,12 @@ class VoicemailManager:
RNS.log(f"Error during voicemail session: {e}", RNS.LOG_ERROR)
if self.is_recording:
self.stop_recording()
+ finally:
+ try:
+ if not prev_receive_muted:
+ self.telephone_manager.unmute_receive()
+ except Exception:
+ pass
threading.Thread(target=session_job, daemon=True).start()

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b7c71492..d5dddd50 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -509,6 +509,7 @@ import ChangelogModal from "./ChangelogModal.vue";
import TutorialModal from "./TutorialModal.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
+import ToneGenerator from "../js/ToneGenerator";
import logoUrl from "../assets/images/logo.png";
export default {
@@ -563,6 +564,7 @@ export default {
isSpeakerMuting: false,
endedTimeout: null,
ringtonePlayer: null,
+ toneGenerator: new ToneGenerator(),
isFetchingRingtone: false,
initiationStatus: null,
initiationTargetHash: null,
@@ -621,6 +623,7 @@ export default {
clearInterval(this.appInfoInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.stopRingtone();
+ this.toneGenerator.stop();
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -736,6 +739,12 @@ export default {
this.initiationStatus = json.status;
this.initiationTargetHash = json.target_hash;
this.initiationTargetName = json.target_name;
+
+ if (this.initiationStatus === "Ringing...") {
+ this.toneGenerator.playRingback();
+ } else if (this.initiationStatus === null) {
+ this.toneGenerator.stop();
+ }
break;
}
case "new_voicemail": {
@@ -745,10 +754,17 @@ export default {
this.updateTelephoneStatus();
break;
}
- case "telephone_call_established":
+ case "telephone_call_established": {
+ this.stopRingtone();
+ this.ringtonePlayer = null;
+ this.toneGenerator.stop();
+ this.updateTelephoneStatus();
+ break;
+ }
case "telephone_call_ended": {
this.stopRingtone();
this.ringtonePlayer = null;
+ this.toneGenerator.playBusyTone();
this.updateTelephoneStatus();
break;
}
@@ -1035,11 +1051,24 @@ export default {
// update ui
this.activeCall = newCall;
+ if (this.activeCall) {
+ this.toneGenerator.stop();
+ }
this.voicemailStatus = response.data.voicemail;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
+ // Handle outgoing ringback tone
+ if (this.initiationStatus === "Ringing...") {
+ this.toneGenerator.playRingback();
+ } else if (!this.initiationStatus && !this.activeCall) {
+ // Only stop if we're not ringing or in a call
+ // This might be too aggressive if called during a transition,
+ // but toneGenerator.stop() is safe to call multiple times.
+ this.toneGenerator.stop();
+ }
+
// Handle power management for calls
if (ElectronUtils.isElectron()) {
if (this.activeCall) {
@@ -1111,6 +1140,7 @@ export default {
// If call just ended, show ended state for a few seconds
if (oldCall != null && this.activeCall == null) {
this.lastCall = oldCall;
+ this.toneGenerator.playBusyTone();
// Trigger history refresh
GlobalEmitter.emit("telephone-history-updated");

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 7b8f3f5c..f00fd8bb 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -242,7 +242,7 @@
<!-- content -->
<div
- v-if="chatItem.lxmf_message.content"
+ v-if="chatItem.lxmf_message.content && !getParsedItems(chatItem)?.isOnlyPaperMessage"
class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
:style="{
'font-family': 'inherit',
@@ -475,13 +475,22 @@
"
>
<!-- delete message -->
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
- @click.stop="deleteChatItem(chatItem)"
- >
- Delete
- </button>
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
+ @click.stop="deleteChatItem(chatItem)"
+ >
+ Delete
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-700 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
+ @click.stop="showRawMessage(chatItem)"
+ >
+ Raw LXM
+ </button>
+ </div>
</div>
</div>
@@ -1058,6 +1067,47 @@
generatedPaperMessageUri = null;
"
/>
+
+ <!-- Raw Message Modal -->
+ <Transition
+ enter-active-class="transition ease-out duration-200"
+ enter-from-class="opacity-0 scale-95"
+ enter-to-class="opacity-100 scale-100"
+ leave-active-class="transition ease-in duration-150"
+ leave-from-class="opacity-100 scale-100"
+ leave-to-class="opacity-0 scale-95"
+ >
+ <div
+ v-if="isRawMessageModalOpen"
+ class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
+ @click.self="isRawMessageModalOpen = false"
+ >
+ <div class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">Raw LXMF Message</h3>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
+ @click="isRawMessageModalOpen = false"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+ <div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
+ <pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{ JSON.stringify(rawMessageData, null, 2) }}</pre>
+ </div>
+ <div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
+ <button
+ type="button"
+ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold transition-colors"
+ @click="isRawMessageModalOpen = false"
+ >
+ Close
+ </button>
+ </div>
+ </div>
+ </div>
+ </Transition>
</template>
<script>
@@ -1173,6 +1223,8 @@ export default {
},
isPaperMessageModalOpen: false,
paperMessageHash: null,
+ isRawMessageModalOpen: false,
+ rawMessageData: null,
hasTranslator: false,
translatorLanguages: [],
};
@@ -1549,9 +1601,18 @@ export default {
}
// Parse paper message link
- const paperMatch = content.match(/(lxm|lxmf):\/\/[a-zA-Z0-9+/=]+/i);
+ const paperMatch = content.match(/(lxm|lxmf):\/\/[a-zA-Z0-9+/=._-]+/i);
if (paperMatch) {
items.paperMessage = paperMatch[0];
+ // if content is only the paper message, or it already contains the detected text,
+ // we'll hide the raw content div to avoid double rendering.
+ const trimmedContent = content.trim();
+ if (
+ trimmedContent === items.paperMessage ||
+ trimmedContent.includes("Paper Message detected")
+ ) {
+ items.isOnlyPaperMessage = true;
+ }
}
return items;
@@ -1971,6 +2032,20 @@ export default {
chatItem.is_actions_expanded = false;
}
},
+ async showRawMessage(chatItem) {
+ try {
+ // we'll try to get the URI first as it contains the raw signed message
+ const response = await window.axios.get(`/api/v1/lxmf-messages/${chatItem.lxmf_message.hash}/uri`);
+ this.rawMessageData = {
+ ...chatItem.lxmf_message,
+ raw_uri: response.data.uri,
+ };
+ } catch (e) {
+ // if URI is not available (message no longer in router), we show what we have
+ this.rawMessageData = { ...chatItem.lxmf_message };
+ }
+ this.isRawMessageModalOpen = true;
+ },
async downloadAndDecodeAudio(chatItem) {
if (this.isDownloadingAudio[chatItem.lxmf_message.hash]) return;

diff --git a/meshchatx/src/frontend/js/ToneGenerator.js b/meshchatx/src/frontend/js/ToneGenerator.js
new file mode 100644
index 00000000..fee797ab
--- /dev/null
+++ b/meshchatx/src/frontend/js/ToneGenerator.js
@@ -0,0 +1,122 @@
+export default class ToneGenerator {
+ constructor() {
+ this.audioCtx = null;
+ this.oscillator = null;
+ this.gainNode = null;
+ this.timeoutId = null;
+ }
+
+ _initAudioContext() {
+ if (!this.audioCtx) {
+ this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ }
+
+ playRingback() {
+ this._initAudioContext();
+ this.stop();
+
+ const play = () => {
+ const osc1 = this.audioCtx.createOscillator();
+ const osc2 = this.audioCtx.createOscillator();
+ const gain = this.audioCtx.createGain();
+
+ osc1.frequency.value = 440;
+ osc2.frequency.value = 480;
+ gain.gain.value = 0.1;
+
+ osc1.connect(gain);
+ osc2.connect(gain);
+ gain.connect(this.audioCtx.destination);
+
+ osc1.start();
+ osc2.start();
+
+ this.oscillator = [osc1, osc2];
+ this.gainNode = gain;
+
+ // Stop after 2 seconds
+ setTimeout(() => {
+ if (this.oscillator === osc1 || (Array.isArray(this.oscillator) && this.oscillator.includes(osc1))) {
+ gain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 0.5);
+ setTimeout(() => {
+ osc1.stop();
+ osc2.stop();
+ osc1.disconnect();
+ osc2.disconnect();
+ gain.disconnect();
+ }, 500);
+ }
+ }, 2000);
+
+ // Repeat every 6 seconds
+ this.timeoutId = setTimeout(play, 6000);
+ };
+
+ play();
+ }
+
+ playBusyTone() {
+ this._initAudioContext();
+ this.stop();
+
+ const play = () => {
+ const osc = this.audioCtx.createOscillator();
+ const gain = this.audioCtx.createGain();
+
+ osc.frequency.value = 480;
+ gain.gain.value = 0.1;
+
+ osc.connect(gain);
+ gain.connect(this.audioCtx.destination);
+
+ osc.start();
+
+ this.oscillator = osc;
+ this.gainNode = gain;
+
+ // Stop after 0.5 seconds
+ setTimeout(() => {
+ if (this.oscillator === osc) {
+ osc.stop();
+ osc.disconnect();
+ gain.disconnect();
+ }
+ }, 500);
+
+ // Repeat every 1 second
+ this.timeoutId = setTimeout(play, 1000);
+ };
+
+ play();
+
+ // Auto-stop busy tone after 4 seconds (4 cycles)
+ setTimeout(() => this.stop(), 4000);
+ }
+
+ stop() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+
+ if (this.oscillator) {
+ if (Array.isArray(this.oscillator)) {
+ this.oscillator.forEach(osc => {
+ try { osc.stop(); } catch (e) {}
+ try { osc.disconnect(); } catch (e) {}
+ });
+ } else {
+ try { this.oscillator.stop(); } catch (e) {}
+ try { this.oscillator.disconnect(); } catch (e) {}
+ }
+ this.oscillator = null;
+ }
+
+ if (this.gainNode) {
+ try { this.gainNode.disconnect(); } catch (e) {}
+ this.gainNode = null;
+ }
+ }
+}
+


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────